Étude préliminaire de la fonctionnalité “Détecter les sujets d’insatisfaction” et “Labelliser automatiquement les photos postées”¶

         ============       Synthèse des résultats de l'analyse      ============
In [106]:
import ipywidgets as widgets
from IPython.display import Image
from IPython.core.display import HTML 
from IPython.display import display
In [107]:
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning) 
In [108]:
import numpy as np
import pandas as pd
import joblib

import seaborn as sns
import matplotlib.pyplot as plt

# Ling
import spacy
from collections import Counter
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import sent_tokenize, word_tokenize, WordPunctTokenizer, RegexpTokenizer
from nltk import ngrams
from wordcloud import WordCloud

#Gensim
import gensim
import gensim.corpora as corpora
from gensim.utils import simple_preprocess
from gensim.models.coherencemodel import CoherenceModel
from gensim.models.ldamodel import LdaModel

#CV
import cv2
from PIL import Image
from skimage.io import imshow, imread
from skimage.transform import resize
from skimage.color import rgb2gray

# ConvNet
from keras.applications.vgg16 import preprocess_input, decode_predictions, VGG16
from keras.models import Model
from tensorflow.keras.utils import img_to_array, load_img

#Vis
import pyLDAvis
import pyLDAvis.gensim
import plotly.express as px
import plotly.offline as py

Enjeux¶

Mise en place d'une nouvelle fonctionnalité de collaboration dans le cadre de l'amélioration de la plateforme Avis Restau :

  • Possibilité pour les utilisateurs de poster des avis et des photos sur leur restaurant préféré.
  • Possibilité pour l’entreprise de mieux comprendre les avis postés par les utilisateurs.
  • Souhait de labelliser automatiquement les photos postées sur la plateforme.

Objectifs¶

  1. Analyser les commentaires négatifs pour détecter les différents sujets d’insatisfaction.
  2. Analyser les photos pour déterminer leurs catégories (nourriture, décor dans le restaurant ou à l’extérieur du restaurant). Faire une étude de faisabilité, c'est-à-dire savoir rapidement :
  • si on arrive à séparer de façon simple les images (simplement via une représentation en 2D)
  • si la séparation automatique selon la catégorie réelle (classification non supervisée) est possible.
  1. Collecter de nouvelles données via l’API Yelp. Valider la faisabilité de la solution en collectant les informations relatives à environ 200 restaurants pour une ville en utilisant l’API.

Détecter les sujets d’insatisfaction¶

Jeu de données¶

Les fichiers json suivants ont été téléchargés depuis le site Yelp :

  • yelp_academic_dataset_business.json
  • yelp_academic_dataset_review.json

Nous avons parcouru les lignes et extrait les informations dont nous avons besoin :

  • le terme restaurants de la feature categories
  • une partie des commentaires

Au final, nous avons créé le dataframe regroupant toutes les données :

In [109]:
# Chargement du jeu de données
data = joblib.load('df_depart')
In [110]:
data.head()
Out[110]:
business_id name address city state postal_code review_count is_open attributes categories review_id user_id stars text date
0 MTSW4McQd7CbVtyjqoe9mw St Honore Pastries 935 Race St Philadelphia PA 19107 80 1 {'RestaurantsDelivery': 'False', 'OutdoorSeati... Restaurants, Food, Bubble Tea, Coffee & Tea, B... BXQcBN0iAi1lAUxibGLFzA 6_SpY41LIHZuIaiDs5FMKA 4.0 This is nice little Chinese bakery in the hear... 2014-05-26 01:09:53
1 MTSW4McQd7CbVtyjqoe9mw St Honore Pastries 935 Race St Philadelphia PA 19107 80 1 {'RestaurantsDelivery': 'False', 'OutdoorSeati... Restaurants, Food, Bubble Tea, Coffee & Tea, B... uduvUCvi9w3T2bSGivCfXg tCXElwhzekJEH6QJe3xs7Q 4.0 This is the bakery I usually go to in Chinatow... 2013-10-05 15:19:06
2 MTSW4McQd7CbVtyjqoe9mw St Honore Pastries 935 Race St Philadelphia PA 19107 80 1 {'RestaurantsDelivery': 'False', 'OutdoorSeati... Restaurants, Food, Bubble Tea, Coffee & Tea, B... a0vwPOqDXXZuJkbBW2356g WqfKtI-aGMmvbA9pPUxNQQ 5.0 A delightful find in Chinatown! Very clean, an... 2013-10-25 01:34:57
3 MTSW4McQd7CbVtyjqoe9mw St Honore Pastries 935 Race St Philadelphia PA 19107 80 1 {'RestaurantsDelivery': 'False', 'OutdoorSeati... Restaurants, Food, Bubble Tea, Coffee & Tea, B... MKNp_CdR2k2202-c8GN5Dw 3-1va0IQfK-9tUMzfHWfTA 5.0 I ordered a graduation cake for my niece and i... 2018-05-20 17:58:57
4 MTSW4McQd7CbVtyjqoe9mw St Honore Pastries 935 Race St Philadelphia PA 19107 80 1 {'RestaurantsDelivery': 'False', 'OutdoorSeati... Restaurants, Food, Bubble Tea, Coffee & Tea, B... D1GisLDPe84Rrk_R4X2brQ EouCKoDfzaVG0klEgdDvCQ 4.0 HK-STYLE MILK TEA: FOUR STARS\n\nNot quite su... 2013-10-25 02:31:35
In [111]:
print("Dimensions du jeu de données :",data.shape)
Dimensions du jeu de données : (72125, 15)

Extraction d'avis négatifs¶

Les commentaires rédigés par des personnes insatisfaites se traduisent par le nombre de "stars" attribuées :

In [112]:
plt.figure(figsize=(14,6))
sns.countplot(x='stars', data=data)
plt.title('Distribution des notes attribuées')
plt.xlabel('Note attribuée')
plt.ylabel('Nombre de clients')
plt.show()

La plupart des notes sont positives. Pour notre analyse, nous avons utilisé les lignes contenant 1 et 2 étoiles, car les avis notés à 1 étoile ne sont pas suffisamment nombreux.

In [113]:
# Chargement du jeu de données
data = joblib.load('df_clean')
In [114]:
print("Dimensions du jeu de données :",data.shape)
Dimensions du jeu de données : (13536, 16)

Prétraitement¶

Le preprocessing du texte se traduit par plusieurs actions : création du corpus, tokenisation, nettoyage et normalisation.

Word Cloud avant le nettoyage

Un coup d'oeil sur le contenu du corpus avant le nettoyage :

In [115]:
corpus = data[['text']].copy()
corpus = corpus.text.to_dict()
corpus_str=str(corpus.values())
In [116]:
wordcloud = WordCloud(random_state=8,
                      normalize_plurals=False,
                      width=600, height=300,
                      max_words=300)
wordcloud.generate(corpus_str)
Out[116]:
<wordcloud.wordcloud.WordCloud at 0x7f33579fdeb0>
In [117]:
fig, ax = plt.subplots(1, 1, figsize=(16, 12))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()

Le nuage de mots est incompréhensible.

Nous remarquons la présence de nombreux termes qui n'apportent aucun sens à l'analyse linguistique :

  • les déterminants et les prépositions
  • les adjectifs et les adverbes (great, good, bad, well, awful, nice, little)

L'objectif est d'identifier les sujets d'insatisfaction. Comme le corpus est constitué d'avis négatifs uniquement, nous pouvons enlever les adjectifs et les adverbes en toute sérénité. Idem pour les déterminants et les prépositions.

Tokénisation

Dans un premier temps une séparation d'éléments d'une phrase en tokens :

In [118]:
corpus_list=list(corpus.values())
sentence = corpus_list[:2]
In [119]:
def sent_to_words(text):
    for sentence in text:
        yield(gensim.utils.simple_preprocess(str(sentence), deacc=True))
In [120]:
extrait_normalized = list(sent_to_words(sentence))
print(extrait_normalized)
[['wife', 'and', 'have', 'eaten', 'lunch', 'here', 'few', 'times', 'over', 'the', 'past', 'weeks', 'always', 'take', 'out', 'and', 'we', 'have', 'never', 'dined', 'in', 'for', 'the', 'most', 'part', 'the', 'lunches', 'have', 'been', 'ok', 'nothing', 'real', 'special', 'to', 'brag', 'about', 'on', 'the', 'last', 'visit', 'we', 'ordered', 'greek', 'salad', 'and', 'two', 'bowls', 'of', 'chili', 'the', 'bar', 'tender', 'is', 'great', 'lady', 'to', 'work', 'with', 'and', 'pleasant', 'every', 'time', 'we', 'call', 'in', 'an', 'order', 'we', 'are', 'told', 'the', 'estimated', 'time', 'it', 'will', 'take', 'to', 'be', 'ready', 'usually', 'minutes', 'every', 'time', 'we', 'arrive', 'generally', 'between', 'minutes', 'we', 'have', 'to', 'wait', 'an', 'additional', 'minutes', 'this', 'last', 'visit', 'was', 'no', 'different', 'paid', 'the', 'bill', 'about', 'and', 'went', 'back', 'to', 'the', 'office', 'to', 'eat', 'very', 'disappointed', 'in', 'the', 'salad', 'as', 'there', 'was', 'virtually', 'no', 'lettuce', 'and', 'only', 'wedge', 'of', 'tomato', 'the', 'chili', 'was', 'served', 'in', 'ounce', 'styrofoam', 'go', 'cups', 'the', 'chili', 'was', 'luke', 'warm', 'at', 'best', 'by', 'the', 'way', 'the', 'drive', 'takes', 'minutes', 'to', 'get', 'to', 'my', 'office', 'from', 'the', 'retaraunt', 'have', 'found', 'the', 'place', 'an', 'ok', 'place', 'as', 'restaurant', 'but', 'certainly', 'appears', 'to', 'be', 'nothing', 'special'], ['after', 'about', 'minutes', 'of', 'waiting', 'patiently', 'for', 'any', 'form', 'of', 'life', 'to', 'serve', 'us', 'chef', 'came', 'out', 'and', 'asked', 'if', 'we', 'had', 'been', 'served', 'he', 'sent', 'over', 'waitress', 'my', 'boyfriend', 'claims', 'his', 'budweiser', 'didn', 'taste', 'right', 'and', 'his', 'salad', 'had', 'slight', 'brown', 'tint', 'to', 'it', 'and', 'barely', 'any', 'green', 'he', 'got', 'steak', 'and', 'broccoli', 'the', 'steak', 'was', 'tiny', 'and', 'shitty', 'but', 'the', 'broccoli', 'was', 'perfecto', 'got', 'half', 'burnt', 'salmon', 'bacon', 'wrap', 'then', 'went', 'to', 'the', 'bathroom', 'and', 'felt', 'like', 'was', 'peeing', 'outside', 'in', 'the', 'degree', 'weather', 'went', 'back', 'to', 'the', 'table', 'and', 'waited', 'for', 'the', 'waitress', 'for', 'bout', 'mins', 'then', 'took', 'her', 'the', 'credit', 'card', 'explained', 'to', 'the', 'manager', 'how', 'cold', 'it', 'is', 'in', 'the', 'women', 'bathroom', 'he', 'said', 'would', 'you', 'like', 'me', 'to', 'bring', 'in', 'the', 'fire', 'pit', 'from', 'outside', 'and', 'light', 'fire', 'for', 'you', 'while', 'laughing', 'at', 'his', 'own', 'joke', 'another', 'worker', 'sitting', 'next', 'to', 'him', 'said', 'it', 'the', 'same', 'in', 'the', 'men', 'bathroom', 'my', 'dick', 'shrivels', 'up', 'every', 'time', 'go', 'in', 'there', 'this', 'place', 'must', 'be', 'drug', 'front']]

Stopwords

Ensuite, suppression de termes inutiles, qui n'apportent pas de sens :

In [121]:
stop_words = stopwords.words('english')


stop_words.extend([
    # Mots inutiles repérés dans le corpus et ajoutés à la liste
    'not','ve',"will","la","hi","till","bf","idk","bf","fcc","fi",
    # Mots repérés lors du LDA qui n'apportent pas de sens aux topics identifiés
    "go","get"])


def remove_stopwords(texts):
    return [[word for word in simple_preprocess(str(doc)) if word not in stop_words] for doc in texts]
In [122]:
extrait_no_stopwords = remove_stopwords(extrait_normalized)
print(extrait_no_stopwords)
[['wife', 'eaten', 'lunch', 'times', 'past', 'weeks', 'always', 'take', 'never', 'dined', 'part', 'lunches', 'ok', 'nothing', 'real', 'special', 'brag', 'last', 'visit', 'ordered', 'greek', 'salad', 'two', 'bowls', 'chili', 'bar', 'tender', 'great', 'lady', 'work', 'pleasant', 'every', 'time', 'call', 'order', 'told', 'estimated', 'time', 'take', 'ready', 'usually', 'minutes', 'every', 'time', 'arrive', 'generally', 'minutes', 'wait', 'additional', 'minutes', 'last', 'visit', 'different', 'paid', 'bill', 'went', 'back', 'office', 'eat', 'disappointed', 'salad', 'virtually', 'lettuce', 'wedge', 'tomato', 'chili', 'served', 'ounce', 'styrofoam', 'cups', 'chili', 'luke', 'warm', 'best', 'way', 'drive', 'takes', 'minutes', 'office', 'retaraunt', 'found', 'place', 'ok', 'place', 'restaurant', 'certainly', 'appears', 'nothing', 'special'], ['minutes', 'waiting', 'patiently', 'form', 'life', 'serve', 'us', 'chef', 'came', 'asked', 'served', 'sent', 'waitress', 'boyfriend', 'claims', 'budweiser', 'taste', 'right', 'salad', 'slight', 'brown', 'tint', 'barely', 'green', 'got', 'steak', 'broccoli', 'steak', 'tiny', 'shitty', 'broccoli', 'perfecto', 'got', 'half', 'burnt', 'salmon', 'bacon', 'wrap', 'went', 'bathroom', 'felt', 'like', 'peeing', 'outside', 'degree', 'weather', 'went', 'back', 'table', 'waited', 'waitress', 'bout', 'mins', 'took', 'credit', 'card', 'explained', 'manager', 'cold', 'women', 'bathroom', 'said', 'would', 'like', 'bring', 'fire', 'pit', 'outside', 'light', 'fire', 'laughing', 'joke', 'another', 'worker', 'sitting', 'next', 'said', 'men', 'bathroom', 'dick', 'shrivels', 'every', 'time', 'place', 'must', 'drug', 'front']]

Lemmatisation

Finalement, une lemmatisation - remplacement des tokens par leur forme canonique :

In [123]:
nlp = spacy.load('en_core_web_sm', disable=["parser", "ner"])
In [124]:
def lemmatization(texts, allowed_postags=['NOUN', 'VERB']):

    texts_out = []
    for sent in texts:
        doc = nlp(" ".join(sent)) 
        texts_out.append([token.lemma_ for token in doc if token.pos_ in allowed_postags])
    return texts_out
In [125]:
extrait_lemmatized = lemmatization(extrait_no_stopwords)
extrait_final = remove_stopwords(extrait_lemmatized)
print(extrait_final)
[['eat', 'lunch', 'time', 'week', 'take', 'dine', 'part', 'lunch', 'brag', 'visit', 'order', 'greek', 'salad', 'bowl', 'chili', 'bar', 'tender', 'lady', 'work', 'time', 'call', 'order', 'tell', 'estimate', 'time', 'take', 'minute', 'time', 'arrive', 'minute', 'wait', 'minute', 'visit', 'pay', 'bill', 'office', 'eat', 'salad', 'lettuce', 'wedge', 'tomato', 'chili', 'serve', 'ounce', 'styrofoam', 'cup', 'chili', 'way', 'drive', 'take', 'minute', 'office', 'retaraunt', 'find', 'place', 'place', 'restaurant', 'appear'], ['minute', 'wait', 'form', 'life', 'serve', 'chef', 'come', 'ask', 'serve', 'send', 'boyfriend', 'claim', 'budweiser', 'taste', 'salad', 'tint', 'steak', 'broccoli', 'steak', 'broccoli', 'perfecto', 'burn', 'salmon', 'bacon', 'wrap', 'bathroom', 'feel', 'pee', 'degree', 'weather', 'table', 'wait', 'bout', 'min', 'take', 'credit', 'card', 'explain', 'manager', 'woman', 'bathroom', 'say', 'bring', 'fire', 'pit', 'fire', 'laugh', 'joke', 'worker', 'sit', 'say', 'man', 'bathroom', 'shrivel', 'time', 'place', 'drug', 'front']]

Comparaison avant - après

Comparons quelques phrases avant et après le prétraitement :

In [126]:
for old_sen, new_sen in zip(sentence, extrait_lemmatized):
    print("Avant :    ", old_sen[:100])
    print("Après :    ", new_sen[:10])
    print()
Avant :     Wife and I have eaten lunch here a few times over the past 6-weeks.  Always a take-out and we have n
Après :     ['eat', 'lunch', 'time', 'week', 'take', 'dine', 'part', 'lunch', 'brag', 'visit']

Avant :     After about 7 minutes of waiting patiently for any form of life to serve us, a chef came out and ask
Après :     ['minute', 'wait', 'form', 'life', 'serve', 'chef', 'come', 'ask', 'serve', 'send']

Word Cloud après le nettoyage¶

In [127]:
data_normalized=joblib.load('normalized_corpus')
In [128]:
wordcloud_text = [x for xs in data_normalized for x in xs]
wc_text = ' '.join(wordcloud_text)
In [129]:
# Instantiate a new wordcloud.
wordcloud = WordCloud(random_state=8,
                      #collocations=True,
                      min_word_length=4,
                      #collocation_threshold=5,
                      normalize_plurals=False,
                      width=600, height=300,
                      max_words=300)
In [130]:
wordcloud.generate(wc_text)
Out[130]:
<wordcloud.wordcloud.WordCloud at 0x7f3338858d30>
In [131]:
fig, ax = plt.subplots(1, 1, figsize=(16, 12))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()

Le résultat est très satisfaisant :

  • le corpus est propre et contient uniquement les noms et les verbes.
  • le pluriel ainsi que la conjugaison ont été neutralisés

Modélisation¶

Le LDA est une technique de modélisation des documents de texte.

Pour identifier les principaux topics de notre corpus, nous allons devoir passer par plusieurs étapes :

  1. Transformation du corpus normalisé en dictionnaire (corpora.Dictionary de Gensim)
  2. Création du bag of words (doc2bow). Le résultat est une matrice creuse contenant chaque terme employé et sa fréquence.
  3. Visualisation du résultat.

Dictionnaire & Bag of words

In [132]:
id2word = corpora.Dictionary(data_normalized)
In [133]:
bow_corpus = []
for text in data_normalized:
    new = id2word.doc2bow(text) # BAG OF WORDS
    bow_corpus.append(new)

print (bow_corpus[:1])
[[(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), (8, 2), (9, 2), (10, 1)]]
In [134]:
print('Combien de tokens dans le dictionnaire : %d' % len(id2word))
print('Combien de documents dans le BOW : %d' % len(bow_corpus))
Combien de tokens dans le dictionnaire : 8909
Combien de documents dans le BOW : 9946

Coherence score

Le Coherence Score est l'une des principales techniques utilisées pour estimer le nombre de topics. Il évalue un seul topic en mesurant le degré de similitude sémantique entre les mots les mieux notés au sein du topic donné.

In [135]:
def compute_coherence_values(dictionary, corpus, texts, limit, start=2, step=3):
    """
    Compute c_v coherence for various number of topics

    Parameters:
    ----------
    dictionary : Gensim dictionary
    corpus : Gensim corpus
    texts : List of input texts
    limit : Max num of topics

    Returns:
    -------
    model_list : List of LDA topic models
    coherence_values : Coherence values corresponding to the LDA model with respective number of topics
    """
    coherence_values = []
    model_list = []
    for num_topics in range(start, limit, step):
        model=LdaModel(corpus=corpus, id2word=dictionary, num_topics=num_topics)
        model_list.append(model)
        coherencemodel = CoherenceModel(model=model, texts=texts, dictionary=dictionary, coherence='c_v')
        coherence_values.append(coherencemodel.get_coherence())

    return model_list, coherence_values
In [136]:
model_list, coherence_values = compute_coherence_values(dictionary=id2word, corpus=bow_corpus, texts=data_normalized, start=2, limit=40, step=6)
In [137]:
plt.figure(figsize=(14, 6))
limit = 40
start = 2
step = 6
x = range(start, limit, step)
plt.plot(x, coherence_values)
plt.title("Coherence score pour 40 topics")
plt.xlabel("Nombre de topics")
plt.ylabel("Coherence score")
plt.legend(("coherence_values"), loc='best')
plt.show()

Le graphique ci-dessus montre que le score de cohérence baisse avec le nombre de topics. Il semblerait que le nombre de topics optimal se situe autour de 5.

Affichons les scores exacts pour avoir une idée précise :

In [138]:
for m, cv in zip(x, coherence_values):
    print("Nb de topics =", m, ", Coherence Value est de", round(cv, 4))
Nb de topics = 2 , Coherence Value est de 0.4108
Nb de topics = 8 , Coherence Value est de 0.3949
Nb de topics = 14 , Coherence Value est de 0.3717
Nb de topics = 20 , Coherence Value est de 0.4077
Nb de topics = 26 , Coherence Value est de 0.3642
Nb de topics = 32 , Coherence Value est de 0.3643
Nb de topics = 38 , Coherence Value est de 0.3542

Le meilleur score est attribué aux 2 sujets. Dans notre cas, deux sujets d'insatisfaction n'est pas un nombre suffisant. De l'autre côté, huit topics c'est trop, tout en sachant que le Coherence Score continue sa descente entre 7 et 15 topics. Nous allons donc comparer les résultats pour 6, 4 et 8 topics.

LDA à 4 topics¶

In [139]:
lda_model = gensim.models.ldamodel.LdaModel(corpus=bow_corpus,
                                           id2word=id2word,
                                           num_topics=4,
                                           passes=10,
                                           alpha="auto")
In [140]:
pyLDAvis.enable_notebook()
vis = pyLDAvis.gensim.prepare(lda_model, bow_corpus, id2word, mds="mmds", R=30)
vis
/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/pyLDAvis/_prepare.py:228: FutureWarning:

In a future version of pandas all arguments of DataFrame.drop except for the argument 'labels' will be keyword-only

/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/past/builtins/misc.py:45: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  from imp import reload
/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/past/builtins/misc.py:45: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  from imp import reload
/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/past/builtins/misc.py:45: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  from imp import reload
/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/past/builtins/misc.py:45: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  from imp import reload
Out[140]:

Poids des topics

In [141]:
from pprint import pprint
pprint(lda_model.print_topics())
[(0,
  '0.047*"burger" + 0.037*"fry" + 0.012*"cake" + 0.009*"taste" + '
  '0.009*"ice_cream" + 0.008*"donut" + 0.007*"bun" + 0.006*"shake" + '
  '0.006*"location" + 0.005*"drive"'),
 (1,
  '0.083*"order" + 0.039*"pizza" + 0.020*"time" + 0.019*"say" + 0.018*"call" + '
  '0.017*"take" + 0.013*"tell" + 0.012*"ask" + 0.010*"give" + 0.010*"wing"'),
 (2,
  '0.049*"food" + 0.028*"place" + 0.024*"service" + 0.018*"time" + '
  '0.018*"come" + 0.012*"restaurant" + 0.012*"order" + 0.011*"wait" + '
  '0.011*"take" + 0.011*"table"'),
 (3,
  '0.030*"taste" + 0.018*"chicken" + 0.017*"order" + 0.014*"sauce" + '
  '0.013*"food" + 0.012*"flavor" + 0.012*"sandwich" + 0.012*"place" + '
  '0.012*"meat" + 0.011*"fry"')]

WordCloud par topic

Les mots les plus exposés sont ceux les plus importants pour un topic donné :

In [142]:
import matplotlib.colors as mcolors
# more colors: 'mcolors.XKCD_COLORS'
cols = [color for name, color in mcolors.TABLEAU_COLORS.items()]

cloud = WordCloud(stopwords=stop_words,
                  background_color='white',
                  width=2500,
                  height=1800,
                  max_words=10,
                  colormap='tab10',
                  color_func=lambda *args, **kwargs: cols[i],
                  prefer_horizontal=1.0)

topics = lda_model.show_topics(formatted=False)

fig, axes = plt.subplots(2, 2, figsize=(10, 8), sharex=True, sharey=True)

for i, ax in enumerate(axes.flat):
    fig.add_subplot(ax)
    topic_words = dict(topics[i][1])
    cloud.generate_from_frequencies(topic_words, max_font_size=300)
    plt.gca().imshow(cloud)
    plt.gca().set_title('Topic ' + str(i), fontdict=dict(size=16))
    plt.gca().axis('off')


plt.subplots_adjust(wspace=0, hspace=0)
plt.axis('off')
plt.margins(x=0, y=0)
plt.tight_layout()
plt.show()

Combien de documents pour chaque topic

Finalement, nous allons vérifier comment sont répartis les 4 topics dans tout le corpus :

In [143]:
def format_topics_sentences(ldamodel=lda_model, corpus=corpus, texts=data):
    # Init output
    sent_topics_df = pd.DataFrame()

    # Get main topic in each document
    for i, row in enumerate(ldamodel[corpus]):
        row = sorted(row, key=lambda x: (x[1]), reverse=True)
        # Get the Dominant topic, Perc Contribution and Keywords for each document
        for j, (topic_num, prop_topic) in enumerate(row):
            if j == 0:  # => dominant topic
                wp = ldamodel.show_topic(topic_num)
                topic_keywords = ", ".join([word for word, prop in wp])
                sent_topics_df = sent_topics_df.append(pd.Series([int(topic_num), round(prop_topic,4), topic_keywords]), ignore_index=True)
            else:
                break
    sent_topics_df.columns = ['Dominant_Topic', 'Perc_Contribution', 'Topic_Keywords']

    # Add original text to the end of the output
    contents = pd.Series(texts)
    sent_topics_df = pd.concat([sent_topics_df, contents], axis=1)
    return(sent_topics_df)
In [144]:
df_topic_sents_keywords = format_topics_sentences(ldamodel=lda_model, corpus=bow_corpus, texts=corpus_list)
In [145]:
# Number of Documents for Each Topic
topic_counts = df_topic_sents_keywords['Dominant_Topic'].value_counts()

# Percentage of Documents for Each Topic
topic_contribution = round(topic_counts/topic_counts.sum(), 4)

# Concatenate Column wise
df_dominant_topics = pd.concat([topic_counts, topic_contribution], axis=1)

# Change Column names
df_dominant_topics.columns = ['Nb Docs', '% Docs']
df_dominant_topics
Out[145]:
Nb Docs % Docs
2.0 7124 0.7163
3.0 1629 0.1638
1.0 1006 0.1011
0.0 187 0.0188

TSNE en 3D¶

La représentation en 3 dimensions, avec l'algorithme t-SNE, nous permettra de visualiser nos documents par topic.

In [146]:
# Get topic weights and dominant topics
from sklearn.manifold import TSNE
from bokeh.plotting import figure, output_file, show
from bokeh.models import Label
from bokeh.io import output_notebook
In [147]:
# Get topic weights
topic_weights = []
for i, row_list in enumerate(lda_model[bow_corpus]):
    topic_weights.append([w for i, w in row_list])
In [148]:
# Array of topic weights    
arr = pd.DataFrame(topic_weights).fillna(0).values

# Dominant topic number in each doc
topic_num = np.argmax(arr, axis=1)

# tSNE Dimension Reduction
tsne_model = TSNE(n_components=3, verbose=1, random_state=0, angle=.99, init='pca')
tsne_lda = tsne_model.fit_transform(arr)
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/manifold/_t_sne.py:790: FutureWarning:

The default learning rate in TSNE will change from 200.0 to 'auto' in 1.2.

[t-SNE] Computing 91 nearest neighbors...
[t-SNE] Indexed 9946 samples in 0.010s...
[t-SNE] Computed neighbors for 9946 samples in 0.199s...
[t-SNE] Computed conditional probabilities for sample 1000 / 9946
[t-SNE] Computed conditional probabilities for sample 2000 / 9946
[t-SNE] Computed conditional probabilities for sample 3000 / 9946
[t-SNE] Computed conditional probabilities for sample 4000 / 9946
[t-SNE] Computed conditional probabilities for sample 5000 / 9946
[t-SNE] Computed conditional probabilities for sample 6000 / 9946
[t-SNE] Computed conditional probabilities for sample 7000 / 9946
[t-SNE] Computed conditional probabilities for sample 8000 / 9946
[t-SNE] Computed conditional probabilities for sample 9000 / 9946
[t-SNE] Computed conditional probabilities for sample 9946 / 9946
[t-SNE] Mean sigma: 0.000766
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/manifold/_t_sne.py:982: FutureWarning:

The PCA initialization in TSNE will change to have the standard deviation of PC1 equal to 1e-4 in 1.2. This will ensure better convergence.

[t-SNE] KL divergence after 250 iterations with early exaggeration: 63.128693
[t-SNE] KL divergence after 1000 iterations: 0.588001
In [149]:
topic_weight_df = pd.DataFrame(topic_weights).fillna(0)
topic_weight_df['topic_num'] = topic_weight_df.idxmax(axis=1)
In [150]:
df_tsne_lda = pd.DataFrame(tsne_lda[:,0:3], columns=['tsne1', 'tsne2','tsne3'])
df_tsne_lda["topic_num"] = topic_weight_df["topic_num"]
In [151]:
fig = px.scatter_3d(df_tsne_lda, x='tsne1', y='tsne2', z='tsne3', color='topic_num', opacity=0.8)
fig.update_layout(margin=dict(l=0, r=0, b=0, t=0))

Conclusion

Nous avons réussi à visualiser les mots utilisés pour exprimer l’insatisfaction, et par conséquent définir les sources du mécontentement des clients :

  • la qualité de la nourriture, le goût des plats commandés
  • les délais d'attente du plat ou attente pour se faire servir

Il faudrait bien sûr apporter des améliorations, par exemple peaufiner la sélection des mots-clés. Néanmoins, une première analyse a permis d'identifier les principaux sujets d'insatisfaction.

Labelliser automatiquement les photos postées¶

Jeu de données¶

Vérifions la répartition des labels :

In [152]:
df_photos = joblib.load('data_photos')
In [153]:
plt.figure(figsize=(14,6))
sns.countplot(x='label', data=df_photos)
plt.title('Distribution des labels attribués')
plt.xlabel('Label attribué')
plt.ylabel('Quantité de photos')
plt.show()

df_photos["label"].value_counts(normalize=True)
Out[153]:
food       0.574458
inside     0.271285
outside    0.084895
drink      0.062459
menu       0.006903
Name: label, dtype: float64

Les photos les plus nombreuses (plus de 57%) illustrent la nourriture. Le menu a été le moins photographié par les clients (moins de 1%).

In [154]:
# Chargement du fichier
data_photos = joblib.load('df_photo_samples')

Extraction de 200 photos par catégorie :

In [155]:
data_photos.groupby("label").count()
Out[155]:
photo_id business_id caption
label
drink 123 123 123
food 144 144 144
inside 151 151 151
menu 56 56 56
outside 142 142 142

Affichage d'exemples d'images en fonction du label

In [156]:
from matplotlib.image import imread
PATH = "/home/sylwia/Jupyter/P6_NLP/bdd_yelp/yelp_photos/photos/"

def list_fct(name) :
    list_image_name = [data_photos.iloc[i].photo_id for i in range(len(data_photos)) if data_photos["label"][i]==name]
    return list_image_name
In [157]:
list_labels = ["food", "drink", "inside", "outside","menu"]

for name in list_labels :
    print(name)
    for i in range(3):
        plt.subplot(130 + 1 + i)
        filename = PATH + list_fct(name)[i+10]
        image = imread(filename)
        plt.imshow(image)
    plt.show()
food
drink
inside
outside
menu

Préprocessing¶

Le préprocessing a pour objectif d'améliorer les propriétés d'une image en vue d'un futur entraînement, grâce à tout un ensemble de processus, comme grayscale, equalization, filtrage bruit, contraste, floutage.

Chargement d'une image de test :

In [158]:
def show_image (image, title, cmap_type='gray') :
    plt.figure(num=None, figsize=(10, 8), dpi=80)
    plt.imshow(image)
    plt.axis('off')
    plt.title(title)
    plt.show()
In [159]:
from PIL import Image

img = Image.open("png/dark.jpg")
show_image(img, title="Photo exemple")

La photo est foncée et floue. Essayons d'afficher la photo sous forme d'histogramme.

Dans le contexte d'une image numérique, il s'agit d'un graphique ou un tracé, ce qui donne une idée globale de la distribution d'intensité d'une image. Le tracé contient des valeurs de pixels (allant de 0 à 255) sur l'axe X et le nombre correspondant de pixels sur l'axe Y.

In [160]:
import cv2

img = cv2.imread('png/dark.jpg')
color = ('b','g','r')
plt.figure(figsize=(12, 8))
for i,col in enumerate(color):
    histr = cv2.calcHist([img],[i],None,[256],[0,256])
    plt.plot(histr, color = col)
    plt.xlim([0,256])
plt.title("Histogramme RGB de la photo exemple")
plt.show()

Le bleu et le vert atteignent plus de 3000 valeurs.

Vérifions la taille de l'image :

In [161]:
print('Dimensions : ', img.shape)
Dimensions :  (400, 300, 3)

La définition de notre image est donc de 300 pixels par 400 pixels.

Niveaux de gris¶

Une image en niveaux de gris (2D greyscale) est très utile pour un traitement ultérieur de la segmentation. Un input RGB doit donc être converti en greyscale.

Regardons comment évolue notre image exemple :

In [162]:
def img_comp(original, filtered, title_1, title2):
    fig, (ax1, ax2) = plt.subplots(
        ncols=2, figsize=(10, 8), sharex=True, sharey=True)
    ax1.imshow(original, cmap=plt.cm.gray)
    ax1.set_title(title_1)
    ax1.axis('off')
    ax2.imshow(filtered, cmap=plt.cm.gray)
    ax2.set_title(title2)
    ax2.axis('off')
In [163]:
# l’option as_gray=True
#img_Gray = imread('png/dark.jpg', as_gray=True)
In [164]:
# la fonction rgb2gray()
img1_Gray = imread('png/dark.jpg')
img2_Gray = rgb2gray(img1_Gray)
In [165]:
img_comp(img, img2_Gray, "Image d'origine","Niveaux de gris avec 'rgb2gray'")

Noir et blanc

In [166]:
img_black_white = np.where(img2_Gray>84/256, 0, 1)
plt.figure(num=None, figsize=(6, 4), dpi=80)
imshow(img_black_white, cmap=plt.get_cmap('gray'))
plt.show()
/home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/skimage/io/_plugins/matplotlib_plugin.py:150: UserWarning:

Low image data range; displaying image with stretched contrast.

Egalisation¶

L'objectif d'une égalisation est d'améliorer le contraste de l'image : soit lui redonner du peps, soit l'adoucir.

Une technique permettant de réajuster le contraste d'une image est l'égalisation des histogrammes : il s'agit d'harmoniser la distribution des niveaux de gris de l'image, de sorte que chaque niveau de l'histogramme contienne idéalement le même nombre de pixels.

Avant

In [167]:
hist,bins = np.histogram(img.flatten(),256,[0,256])

# cumulative distribution function
cdf = hist.cumsum()
cdf_normalized = cdf * float(hist.max()) / cdf.max()
In [168]:
plt.plot(cdf_normalized, color = 'b')
plt.hist(img.flatten(),256,[0,256], color = 'r')
plt.xlim([0,256])
plt.legend(('cdf','histogram'), loc = 'upper left')
plt.show()

Après

In [169]:
cdf_m = np.ma.masked_equal(cdf,0)
cdf_m = (cdf_m - cdf_m.min())*255/(cdf_m.max()-cdf_m.min())
cdf = np.ma.filled(cdf_m,0).astype('uint8')
In [170]:
# Génération de la nouvelle image égalisée
img2 = cdf[img]
In [171]:
hist,bins = np.histogram(img2.flatten(),256,[0,256])
cdf = hist.cumsum()
cdf_normalized = cdf * float(hist.max()) / cdf.max()

plt.plot(cdf_normalized, color = 'b')
plt.hist(img2.flatten(),256,[0,256], color = 'r')
plt.xlim([0,256])
plt.legend(('cdf','histogram'), loc = 'upper left')
plt.show()

Vue après égalisation : les pixels sont mieux étendus dans toute la zone entre 0 et 255.

In [172]:
img_comp(img, img2, "Photo originale", "Image égalisée")

Filtrage de bruit

In [173]:
dst = cv2.fastNlMeansDenoisingColored(img2,None,10,10,7,12)
img_comp(img2, dst, "Photo égalisée","Photo débruitée")

SIFT¶

SIFT est une méthode qui permet de détecter et identifier les éléments similaires entre différentes images numériques.

L'étape fondamentale de l'algorithme consiste à calculer ce que l'on appelle les « descripteurs SIFT » des images à étudier. Les descripteurs, à leur tour, nous seront utiles pour créer les bag of visual words - un regroupement des descripteurs qui sont proches entre eux.

Pour effectuer une étude de similarité de descripteurs (rapprocher ceux qui se ressemblent), nous allons faire appel au clustering.

Affichage des descripteurs

In [174]:
img = cv2.imread(PATH + data_photos.iloc[1].photo_id)


# Préprocessing d'images : grayscale & égalisation
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)

# Création des descripteurs
sift = cv2.SIFT_create()
kp, desc = sift.detectAndCompute(gray, None)

img = cv2.drawKeypoints(gray, kp, img)
img = cv2.drawKeypoints(
    gray, kp, img, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)


# Affichage
display(Image.fromarray(img, "RGB"))
print("Descripteurs : ", desc.shape)
print()
print(desc)
Descripteurs :  (815, 128)

[[ 49.   2.   0. ...   0.   0.   0.]
 [  5.  47.  45. ...   8.   1.   5.]
 [138. 120.   2. ...   2.   0.   1.]
 ...
 [  0.   0.   0. ...  20.  11.   3.]
 [ 15.  20.  15. ...   4.   1.  30.]
 [ 48.   7.   1. ...   0.   0.   0.]]

Par la suite, nous allons effectuer une PCA, ensuite une réduction de dimension via T-SNE.

In [175]:
# Chargement du fichier
im_features = joblib.load('im_features')

PCA

In [176]:
#Clustering
from sklearn import manifold, decomposition
from sklearn import cluster, metrics
from sklearn.cluster import KMeans
from sklearn import preprocessing
from sklearn.decomposition import PCA
from sklearn.metrics import silhouette_score
In [177]:
print("Dimensions avant PCA : ", im_features.shape)
pca = decomposition.PCA(n_components=0.99)
feat_pca= pca.fit_transform(im_features)
print("Dimensions après PCA : ", feat_pca.shape)
Dimensions avant PCA :  (616, 844)
Dimensions après PCA :  (616, 1)

TSNE

In [178]:
tsne = manifold.TSNE(n_components=2, perplexity=30, 
                     n_iter=2000, init='random', random_state=6)
X_tsne = tsne.fit_transform(feat_pca)
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/manifold/_t_sne.py:790: FutureWarning:

The default learning rate in TSNE will change from 200.0 to 'auto' in 1.2.

In [179]:
# Création d'un nouveau dataframe
df_tsne = pd.DataFrame(X_tsne[:,0:2], columns=['tsne1', 'tsne2'])
df_tsne["class"] = data_photos["label"]

Images selon les labels

In [180]:
plt.figure(figsize=(10,8))
sns.scatterplot(
    x="tsne1", y="tsne2", hue="class", data=df_tsne, legend="brief",
    palette=sns.color_palette('tab10', n_colors=5), s=50, alpha=0.6)

plt.title('TSNE selon les vraies classes', fontsize = 20, pad = 30, fontweight = 'bold')
plt.xlabel('tsne1', fontsize = 20, fontweight = 'bold')
plt.ylabel('tsne2', fontsize = 20, fontweight = 'bold')
plt.legend(prop={'size': 14})

plt.show()

Images selon les clusters

In [181]:
cls = cluster.KMeans(n_clusters=4, random_state=6)
cls.fit(feat_pca)

df_tsne["cluster"] = cls.labels_
print(df_tsne.shape)
(616, 4)
In [184]:
plt.figure(figsize=(10,8))
sns.scatterplot(
    x="tsne1", y="tsne2",
    hue="cluster",
    palette=sns.color_palette('tab10', n_colors=4), s=50, alpha=0.6,
    data=df_tsne,
    legend="brief")

plt.title('TSNE selon les clusters', fontsize = 20, pad = 30, fontweight = 'bold')
plt.xlabel('tsne1', fontsize = 20, fontweight = 'bold')
plt.ylabel('tsne2', fontsize = 20, fontweight = 'bold')
plt.legend(prop={'size': 14}) 

plt.show()

Calcul ARI

In [185]:
# Chargement du jeu de données
data_photos = joblib.load('data_labels')
In [186]:
# Nouvelle table contenant uniquement les labels encodés
labels = data_photos["labels_bool"]
In [187]:
#data_photos["labels_bool"].unique()
In [188]:
print("ARI : ", metrics.adjusted_rand_score(labels, cls.labels_))
ARI :  0.0002240814777002201

Matrice de confusion

Visualisation de clusters :

In [189]:
# Vue countplot
plt.figure(figsize=(14,6))
sns.countplot(x='cluster', hue='class', data=df_tsne)
plt.title('Visualisation de clusters')
plt.xlabel('Numéro de cluster')
plt.ylabel('Quantité de photos')
plt.show()
In [190]:
# Vue matrix
conf_mat = metrics.confusion_matrix(labels, cls.labels_)
print(conf_mat)
[[121   0   1   1   0]
 [142   2   0   0   0]
 [149   2   0   0   0]
 [ 55   1   0   0   0]
 [140   2   0   0   0]]
In [191]:
# Réalisation manuellement au lieu d'utiliser la fonction "argmax"
def conf_mat_transform(y_true, y_pred, my_corresp) :
    conf_mat = metrics.confusion_matrix(y_true,y_pred)
    
    #corresp = np.argmax(conf_mat, axis=0)
    corresp = my_corresp
    print ("Correspondance des clusters : ", corresp)
    # y_pred_transform = np.apply_along_axis(correspond_fct, 1, y_pred)
    labels = pd.Series(y_true, name="y_true").to_frame()
    labels['y_pred'] = y_pred
    labels['y_pred_transform'] = labels['y_pred'].apply(lambda x : corresp[x]) 
    
    return labels['y_pred_transform']
In [192]:
my_corresp = [2, 1, 4, 3, 0]
cls_labels_transform = conf_mat_transform(labels, cls.labels_, my_corresp)
conf_mat = metrics.confusion_matrix(labels, cls_labels_transform)

print(conf_mat)
print()
print(metrics.classification_report(labels, cls_labels_transform))
Correspondance des clusters :  [2, 1, 4, 3, 0]
[[  0   0 121   1   1]
 [  0   2 142   0   0]
 [  0   2 149   0   0]
 [  0   1  55   0   0]
 [  0   2 140   0   0]]

              precision    recall  f1-score   support

           0       0.00      0.00      0.00       123
           1       0.29      0.01      0.03       144
           2       0.25      0.99      0.39       151
           3       0.00      0.00      0.00        56
           4       0.00      0.00      0.00       142

    accuracy                           0.25       616
   macro avg       0.11      0.20      0.08       616
weighted avg       0.13      0.25      0.10       616

/home/sylwia/.local/lib/python3.9/site-packages/sklearn/metrics/_classification.py:1318: UndefinedMetricWarning:

Precision and F-score are ill-defined and being set to 0.0 in labels with no predicted samples. Use `zero_division` parameter to control this behavior.

/home/sylwia/.local/lib/python3.9/site-packages/sklearn/metrics/_classification.py:1318: UndefinedMetricWarning:

Precision and F-score are ill-defined and being set to 0.0 in labels with no predicted samples. Use `zero_division` parameter to control this behavior.

/home/sylwia/.local/lib/python3.9/site-packages/sklearn/metrics/_classification.py:1318: UndefinedMetricWarning:

Precision and F-score are ill-defined and being set to 0.0 in labels with no predicted samples. Use `zero_division` parameter to control this behavior.

In [193]:
df_cm = pd.DataFrame(conf_mat, index = [label for label in list_labels],
                  columns = [i for i in "01234"])
plt.figure(figsize = (7, 7))
sns.heatmap(df_cm, annot=True, cmap="Greens")
plt.show()

Conclusions

La classification n'est pas parfaite. Seulement 34% d'accuracy pour la totalité des images.

On observe un recall élevé pour une seule catégorie : food - 96 photos sur 123 ont été bien classées. Les autres catégories sont moins bien séparées.

CNN Transfer Learning¶

Convolutional Neural Network / ConvNet - les réseaux de neurones convolutifs, sont des modèles les plus performants pour la classification d'images. Leur architecture est composée de deux blocs principaux :

  • un extracteur de features qui effectue du template matching en appliquant des opérations de filtrage par convolution. La première couche filtre l'image avec plusieurs noyaux de convolution, et renvoie des "feature maps", qui sont ensuite normalisées (avec une fonction d'activation) et/ou redimensionnées.
  • un calcul des probabilités qui nécessite une transformation de valeurs en vecteur contenant autant d'éléments qu'il y a de classes.

Le Transfer Learning - un transfert de connaissances - permet d'utiliser les connaissances acquises par un réseau de neurones déjà existant.

In [194]:
import tensorflow
from tensorflow import keras
print('TensorFlow version:',tensorflow.__version__)
print('Keras version:',keras.__version__)
TensorFlow version: 2.9.1
Keras version: 2.9.0

VGG16 sur une image¶

L'image à classer avec VGG16 :

"Image utilisée pour test VGG16"

In [195]:
# Création du modèle :
model = VGG16(weights='imagenet')
2022-08-23 13:39:14.917652: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcuda.so.1'; dlerror: libcuda.so.1: cannot open shared object file: No such file or directory; LD_LIBRARY_PATH: /home/sylwia/anaconda3/envs/nlp/lib/python3.9/site-packages/cv2/../../lib64:
2022-08-23 13:39:14.917690: W tensorflow/stream_executor/cuda/cuda_driver.cc:269] failed call to cuInit: UNKNOWN ERROR (303)
2022-08-23 13:39:14.917715: I tensorflow/stream_executor/cuda/cuda_diagnostics.cc:156] kernel driver does not appear to be running on this host (sylwia-ThinkPad-T460): /proc/driver/nvidia/version does not exist
2022-08-23 13:39:14.918121: I tensorflow/core/platform/cpu_feature_guard.cc:193] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations:  AVX2 FMA
To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
2022-08-23 13:39:15.120326: W tensorflow/core/framework/cpu_allocator_impl.cc:82] Allocation of 411041792 exceeds 10% of free system memory.
2022-08-23 13:39:15.476882: W tensorflow/core/framework/cpu_allocator_impl.cc:82] Allocation of 411041792 exceeds 10% of free system memory.
2022-08-23 13:39:15.598775: W tensorflow/core/framework/cpu_allocator_impl.cc:82] Allocation of 411041792 exceeds 10% of free system memory.
2022-08-23 13:39:17.614911: W tensorflow/core/framework/cpu_allocator_impl.cc:82] Allocation of 411041792 exceeds 10% of free system memory.
In [196]:
# Chargement et définition de la taille
img = load_img('png/resto.jpg', target_size=(224, 224))

# Conversion en tableau numpy
img = img_to_array(img)
# Ajout dela 4ème dimension
img = img.reshape((1, img.shape[0], img.shape[1], img.shape[2]))
# Prétraitement
img = preprocess_input(img)
In [197]:
# Prédire la classe de l'image (parmi les 1000 classes d'ImageNet)
y = model.predict(img)
1/1 [==============================] - 0s 497ms/step

Le résultat est une liste de classes et leurs probabilités.

In [198]:
# Afficher les 3 classes les plus probables
print('Top 3 :', decode_predictions(y, top=3)[0])
Top 3 : [('n03032252', 'cinema', 0.80120504), ('n04081281', 'restaurant', 0.038507678), ('n03788195', 'mosque', 0.019463148)]

Notre image a été identifiée en tant que cinéma à 80% et en tant que restaurant à seulement 3%.

VGG16 sur toutes les images¶

Le réseau VGG16 a été pré-entraîné sur un problème de classification à 1000 classes.

Notre besoin est diffférent : nous sommes confrontés à un problème de classification à 5 catégories. Nous allons faire appel au Transfer Learning. L'objectif est de remplacer les dernières couches fully-connected qui permettent de ranger l'image dans une des 1000 classes par un classifieur plus adapté au problème.

In [199]:
# Chargement du jeu de données
features_vgg_df = joblib.load('features_vgg_df')

PCA

In [200]:
variance = 0.99

pca = PCA(variance)
pca.fit(features_vgg_df)
feat_vgg_pca = pca.transform(features_vgg_df)

print('Dimensions dataset avant réduction PCA : ' + str(features_vgg_df.shape[1]))
print('Dimensions dataset après réduction PCA : ' + str(pca.n_components_))
Dimensions dataset avant réduction PCA : 4096
Dimensions dataset après réduction PCA : 542

TSNE

In [201]:
tsne = manifold.TSNE(n_components=2, perplexity=30, 
                     n_iter=2000, init='random', random_state=6)
X_tsne = tsne.fit_transform(feat_vgg_pca)
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/manifold/_t_sne.py:790: FutureWarning:

The default learning rate in TSNE will change from 200.0 to 'auto' in 1.2.

In [202]:
# Création d'un nouveau dataframe
df_tsne = pd.DataFrame(X_tsne[:,0:2], columns=['tsne1', 'tsne2'])
df_tsne["class"] = data_photos["label"]

Images selon les labels

In [203]:
plt.figure(figsize=(10,8))
sns.scatterplot(
    x="tsne1", y="tsne2", hue="class", data=df_tsne, legend="brief",
    palette=sns.color_palette('tab10', n_colors=5), s=50, alpha=0.6)

plt.title('TSNE selon les vraies classes', fontsize = 20, pad = 30, fontweight = 'bold')
plt.xlabel('tsne1', fontsize = 20, fontweight = 'bold')
plt.ylabel('tsne2', fontsize = 20, fontweight = 'bold')
plt.legend(prop={'size': 14})

plt.show()

Images selon les clusters

In [204]:
cls = cluster.KMeans(n_clusters=5, random_state=6)
cls.fit(feat_vgg_pca)
Out[204]:
KMeans(n_clusters=5, random_state=6)
In [205]:
df_tsne["cluster"] = cls.labels_
print(df_tsne.shape)
(616, 4)
In [206]:
plt.figure(figsize=(10,8))
sns.scatterplot(
    x="tsne1", y="tsne2",
    hue="cluster",
    palette=sns.color_palette('tab10', n_colors=5), s=50, alpha=0.6,
    data=df_tsne,
    legend="brief")

plt.title('TSNE selon les clusters', fontsize = 20, pad = 30, fontweight = 'bold')
plt.xlabel('tsne1', fontsize = 20, fontweight = 'bold')
plt.ylabel('tsne2', fontsize = 20, fontweight = 'bold')
plt.legend(prop={'size': 14}) 

plt.show()

Affichage des images en fonction du cluster¶

In [207]:
# Clustering avec KMeans
kmeansVGG = KMeans(n_clusters=len(list_labels), random_state=3)
kmeansVGG.fit(feat_vgg_pca)
Out[207]:
KMeans(n_clusters=5, random_state=3)
In [208]:
# Stockage des noms de photos dans une liste
liste_noms_image = []
for i, row in data_photos.iterrows():
    nom = PATH + data_photos.photo_id[i]
    liste_noms_image.append(nom)
liste_noms_image = [name.split('/')[-1] for name in liste_noms_image]
In [209]:
# Stockage de photos au niveau des clusters respectifs
groups = {}
for file, cluster in zip(liste_noms_image, kmeansVGG.labels_):
    if cluster not in groups.keys():
        groups[cluster] = []
        groups[cluster].append(file)
    else:
        groups[cluster].append(file)
In [210]:
# Stockage des résultats dans un dataframe
cluster_groups = pd.DataFrame(groups.items(), columns=['Cluster', 'Image'])
#cluster_groups.head()
In [211]:
# Fonction permettant d'afficher les photos appartenant à un cluster donné
def view_cluster(cluster):
    plt.figure(figsize=(25, 25))
    # gets the list of filenames for a cluster
    files = groups[cluster]
    # only allow up to 15 images to be shown at a time
    if len(files) > 30:
        print(f"Clipping cluster size from {len(files)} to 30")
        files = files[:29]
    # plot each image in the cluster
    for index, file in enumerate(files):
        plt.subplot(8, 8, index+1)
        img = load_img(PATH + file)
        img = np.array(img)
        plt.imshow(img)
        plt.axis('off')
In [212]:
view_cluster(1)
Clipping cluster size from 144 to 30

Ce cluster semble correspondre à la classe "outside".

In [213]:
view_cluster(2)
Clipping cluster size from 137 to 30

Ce cluster correspond à la classe "drink".

In [214]:
view_cluster(3)
Clipping cluster size from 54 to 30

Ce cluster correspond à la classe "menu".

In [215]:
view_cluster(4)
Clipping cluster size from 147 to 30

Ce cluster correspond clairement à la classe "inside".

In [216]:
view_cluster(0)
Clipping cluster size from 134 to 30

Ce cluster correspond au label food.

Calcul ARI

In [217]:
# Calcul ARI
print("ARI : ", metrics.adjusted_rand_score(labels, cls.labels_))
ARI :  0.6749992777265746

Matrice de confusion

In [218]:
# Visualisation des résultats avec un countplot
plt.figure(figsize=(14,6))
sns.countplot(x='cluster', hue='class', data=df_tsne)
plt.title('Visualisation de clusters')
plt.xlabel('Numéro de cluster')
plt.ylabel('Quantité de photos')
plt.show()
In [219]:
# Vue matrix
conf_mat = metrics.confusion_matrix(labels, cls.labels_)
In [220]:
my_corresp = [1, 2, 4, 3, 0]
cls_labels_transform = conf_mat_transform(labels, cls.labels_, my_corresp)
conf_mat = metrics.confusion_matrix(labels, cls_labels_transform)

print(conf_mat)
print()
print(metrics.classification_report(labels, cls_labels_transform))
Correspondance des clusters :  [1, 2, 4, 3, 0]
[[115   2   5   1   0]
 [ 20   0 122   1   1]
 [ 12  11   5   0 123]
 [  0   5   1  48   2]
 [  3 122   0   1  16]]

              precision    recall  f1-score   support

           0       0.77      0.93      0.84       123
           1       0.00      0.00      0.00       144
           2       0.04      0.03      0.04       151
           3       0.94      0.86      0.90        56
           4       0.11      0.11      0.11       142

    accuracy                           0.30       616
   macro avg       0.37      0.39      0.38       616
weighted avg       0.27      0.30      0.28       616

In [221]:
df_cm = pd.DataFrame(conf_mat, index = [label for label in list_labels],
                  columns = [i for i in "01234"])
plt.figure(figsize = (7, 7))
sns.heatmap(df_cm, annot=True, cmap="Oranges")
plt.show()

Conclusions

Les résultats sont très satisfaisants. L'algorithme a bien classé les images de toutes les catégories. Les photos les moins bien séparées appartiennent au label outside - seulement 51 photos reconnues sur 123.

La classification avec VGG16 est de loin meilleure avec 83% d'accuracy pour l'ensemble des catégories.

Validation de la faisabilité¶

Dans un souci de valider la faisabilité de la solution, nous allons collecter de nouvelles données via l'API YELP.

Aperçu des avis¶

In [222]:
# Chargement du jeu de données
df_reviews_api = joblib.load('df_reviews_api')
In [223]:
df_reviews_api.head()
Out[223]:
review_id business_id stars text
0 gKnt3x8FFTduKx_UWakMVA V7lXZKBDzScDeGB8JmnzSA 3 When you look up places to eat in New York Cit...
1 wIT-ElLs-ryrdUSyQrXRsw V7lXZKBDzScDeGB8JmnzSA 5 No word can describe \nVery nice and tender \n...
2 Z70us1M9d-pbwAxkbT4C4A V7lXZKBDzScDeGB8JmnzSA 5 Excellent, GIANT corned beef sandwiches!! Kat...
3 TD9YpWt29UXU_JnpyGQgng 44SY464xDHbvOcjDzRbKkQ 5 One of my favorite ramen places I have been. \...
4 7__yw0Eb3hVRpjD2MgWR-A 44SY464xDHbvOcjDzRbKkQ 3 Went here with friends for dinner.\nThe overal...

Aperçu des images¶

In [224]:
# Chargement du jeu de données
df_photos_api = joblib.load('df_photos_api')
In [225]:
df_photos_api.head()
Out[225]:
photo_url business_id
0 https://s3-media1.fl.yelpcdn.com/bphoto/mrIdx2... V7lXZKBDzScDeGB8JmnzSA
1 https://s3-media1.fl.yelpcdn.com/bphoto/zF3Egq... 44SY464xDHbvOcjDzRbKkQ
2 https://s3-media1.fl.yelpcdn.com/bphoto/MYnXpr... xEnNFXtMLDF5kZDxfaCJgA
3 https://s3-media4.fl.yelpcdn.com/bphoto/xM4eGR... 0CjK3esfpFcxIopebzjFxA
4 https://s3-media1.fl.yelpcdn.com/bphoto/d0XSKE... 4yPqqJDJOQX69gC66YUDkA
In [226]:
from skimage.io import imshow, imread
def show_img_api(new_img) :
    plt.figure(figsize=(10, 8))
    imshow(new_img)
    plt.axis('off')
    plt.title("Image via l'API Yelp")
    plt.show()
In [227]:
api_img = df_photos_api.photo_url[4]
show_img_api(api_img)
In [228]:
api_img = df_photos_api.photo_url[122]
show_img_api(api_img)